/*
 * Copyright (c) 2012-present Christopher J. Brody (aka Chris Brody)
 * Copyright (c) 2005-2010, Nitobi Software Inc.
 * Copyright (c) 2010, IBM Corporation
 */

package io.sqlc;

import android.util.Log;

import java.io.File;

import java.lang.IllegalArgumentException;
import java.lang.Number;

import java.net.URI;
import java.net.URISyntaxException;

import java.util.Map;
import java.util.concurrent.BlockingQueue;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.LinkedBlockingQueue;

// NOTE: more than CordovaPlugin & CallbackContext needed to support
// override of initialize() function.
import org.apache.cordova.*;

import org.json.JSONArray;
import org.json.JSONException;
import org.json.JSONObject;

import java.sql.SQLException;

public class SQLitePlugin extends CordovaPlugin {

    /**
     * Concurrent database runner map.
     *
     * NOTE: no public static accessor to db (runner) map since it is not expected
     * to work properly with db threading.
     *
     * FUTURE TBD put DBRunner into a public class that can provide external
     * accessor.
     *
     * ADDITIONAL NOTE: Storing as Map<String, DBRunner> to avoid portabiity issue
     * between Java 6/7/8 as discussed in:
     * https://gist.github.com/AlainODea/1375759b8720a3f9f094
     *
     * THANKS to @NeoLSN (Jason Yang/楊朝傑) for giving the pointer in:
     * https://github.com/litehelpers/Cordova-sqlite-storage/issues/727
     */
    private Map<String, DBRunner> dbrmap = new ConcurrentHashMap<String, DBRunner>();

    /**
     * NOTE: Using default constructor, no explicit constructor.
     */

    /**
     * Override to load native lib(s). NOTE: cannot do this in static initializer
     * since SQLiteDatabase.loadLibs() needs the app's activity, which seems to be
     * available only from the cordova member (of the superclass). Also, newer
     * versions of Cordova provide pluginInitialize() which can be overridden more
     * easily.
     */
    @Override
    public void initialize(CordovaInterface cordova, CordovaWebView webView) {
        super.initialize(cordova, webView);
        SQLiteAndroidDatabase.initialize(cordova);
    }

    /**
     * Executes the request and returns PluginResult.
     *
     * @param actionAsString The action to execute.
     * @param args           JSONArry of arguments for the plugin.
     * @param cbc            Callback context from Cordova API
     * @return Whether the action was valid.
     */
    @Override
    public boolean execute(String actionAsString, JSONArray args, CallbackContext cbc) {

        Action action;
        try {
            action = Action.valueOf(actionAsString);
        } catch (IllegalArgumentException e) {
            // shouldn't ever happen
            Log.e(SQLitePlugin.class.getSimpleName(), "unexpected error", e);
            return false;
        }

        try {
            return executeAndPossiblyThrow(action, args, cbc);
        } catch (JSONException e) {
            // TODO: signal JSON problem to JS
            Log.e(SQLitePlugin.class.getSimpleName(), "unexpected error", e);
            return false;
        }
    }

    private boolean executeAndPossiblyThrow(Action action, JSONArray args, CallbackContext cbc) throws JSONException {

        boolean status = true;
        JSONObject o;
        String echo_value;
        String dbname;

        switch (action) {
        case echoStringValue:
            o = args.getJSONObject(0);
            echo_value = o.getString("value");
            cbc.success(echo_value);
            break;

        case open:
            o = args.getJSONObject(0);
            dbname = o.getString("name");
            // open database and start reading its queue
            this.startDatabase(dbname, o, cbc);
            break;

        case close:
            o = args.getJSONObject(0);
            dbname = o.getString("path");
            // put request in the q to close the db
            this.closeDatabase(dbname, cbc);
            break;

        case delete:
            o = args.getJSONObject(0);
            dbname = o.getString("path");
            String dblocation = null;
            if (o.has("androidDatabaseLocation"))
                dblocation = o.getString("androidDatabaseLocation");
            deleteDatabase(dbname, dblocation, cbc);

            break;

        case executeSqlBatch:
        case backgroundExecuteSqlBatch:
            JSONObject allargs = args.getJSONObject(0);
            JSONObject dbargs = allargs.getJSONObject("dbargs");
            dbname = dbargs.getString("dbname");
            JSONArray txargs = allargs.getJSONArray("executes");

            if (txargs.isNull(0)) {
                cbc.error("missing executes list");
            } else {
                int len = txargs.length();
                String[] queries = new String[len];
                JSONArray[] jsonparams = new JSONArray[len];

                for (int i = 0; i < len; i++) {
                    JSONObject a = txargs.getJSONObject(i);
                    queries[i] = a.getString("sql");
                    jsonparams[i] = a.getJSONArray("params");
                }

                // put db query in the queue to be executed in the db thread:
                DBQuery q = new DBQuery(queries, jsonparams, cbc);
                DBRunner r = dbrmap.get(dbname);
                if (r != null) {
                    try {
                        r.q.put(q);
                    } catch (Exception e) {
                        Log.e(SQLitePlugin.class.getSimpleName(), "couldn't add to queue", e);
                        cbc.error("couldn't add to queue");
                    }
                } else {
                    cbc.error("database not open");
                }
            }
            break;
        }

        return status;
    }

    /**
     * Clean up and close all open databases.
     */
    @Override
    public void onDestroy() {
        while (!dbrmap.isEmpty()) {
            String dbname = dbrmap.keySet().iterator().next();

            this.closeDatabaseNow(dbname);

            DBRunner r = dbrmap.get(dbname);
            try {
                // stop the db runner thread:
                r.q.put(new DBQuery());
            } catch (Exception e) {
                Log.e(SQLitePlugin.class.getSimpleName(), "couldn't stop db thread", e);
            }
            dbrmap.remove(dbname);
        }
    }

    // --------------------------------------------------------------------------
    // LOCAL METHODS
    // --------------------------------------------------------------------------

    private void startDatabase(String dbname, JSONObject options, CallbackContext cbc) {
        DBRunner r = dbrmap.get(dbname);

        if (r != null) {
            // NO LONGER EXPECTED due to BUG 666 workaround solution:
            cbc.error("INTERNAL ERROR: database already open for db name: " + dbname);
        } else {
            r = new DBRunner(dbname, options, cbc);
            dbrmap.put(dbname, r);
            this.cordova.getThreadPool().execute(r);
        }
    }

    /**
     * Get a database file.
     *
     * @param dbName The name of the database file
     */
    private File getDatabaseFile(String dbname, String dblocation) throws URISyntaxException {
        if (dblocation == null) {
            File dbfile = this.cordova.getActivity().getDatabasePath(dbname);

            if (!dbfile.exists()) {
                dbfile.getParentFile().mkdirs();
            }

            return dbfile;
        }

        return new File(new File(new URI(dblocation)), dbname);
    }

    /**
     * Open a database.
     *
     * @param dbName The name of the database file
     */
    private SQLiteAndroidDatabase openDatabase(String dbname, String dblocation, String key, CallbackContext cbc)
            throws Exception {
        try {
            // ASSUMPTION: no db (connection/handle) is already stored in the map
            // [should be true according to the code in DBRunner.run()]

            File dbfile = getDatabaseFile(dbname, dblocation);
            Log.v("info",
                    "Open sqlite db dbfile=" + dbfile.getAbsolutePath() + " key=" + key + " exists()=" + dbfile.exists()
                            + " canRead()=" + dbfile.canRead() + " lastModified()=" + dbfile.lastModified()
                            + " length()=" + dbfile.length());

            SQLiteAndroidDatabase mydb = new SQLiteAndroidDatabase();
            mydb.open(dbfile, key);

            // NOTE: NO Android locking/closing BUG workaround needed here
            cbc.success();

            return mydb;
        } catch (Exception e) {
            // NOTE: NO Android locking/closing BUG workaround needed here
            cbc.error("can't open database " + e);
            throw e;
        }
    }

    // NOTE: createFromAssets (pre-populated DB) feature is not
    // supported for SQLCipher.

    /**
     * Close a database (in another thread).
     *
     * @param dbName The name of the database file
     */
    private void closeDatabase(String dbname, CallbackContext cbc) {
        DBRunner r = dbrmap.get(dbname);
        if (r != null) {
            try {
                r.q.put(new DBQuery(false, cbc));
            } catch (Exception e) {
                if (cbc != null) {
                    cbc.error("couldn't close database" + e);
                }
                Log.e(SQLitePlugin.class.getSimpleName(), "couldn't close database", e);
            }
        } else {
            if (cbc != null) {
                cbc.success();
            }
        }
    }

    /**
     * Close a database (in the current thread).
     *
     * @param dbname The name of the database file
     */
    private void closeDatabaseNow(String dbname) {
        DBRunner r = dbrmap.get(dbname);

        if (r != null) {
            SQLiteAndroidDatabase mydb = r.mydb;

            if (mydb != null)
                mydb.closeDatabaseNow();
        }
    }

    private void deleteDatabase(String dbname, String dblocation, CallbackContext cbc) {
        DBRunner r = dbrmap.get(dbname);
        if (r != null) {
            try {
                r.q.put(new DBQuery(true, cbc));
            } catch (Exception e) {
                if (cbc != null) {
                    cbc.error("couldn't close database" + e);
                }
                Log.e(SQLitePlugin.class.getSimpleName(), "couldn't close database", e);
            }
        } else {
            boolean deleteResult = this.deleteDatabaseNow(dbname, dblocation);
            if (deleteResult) {
                cbc.success();
            } else {
                cbc.error("couldn't delete database");
            }
        }
    }

    /**
     * Delete a database.
     *
     * @param dbName The name of the database file
     *
     * @return true if successful or false if an exception was encountered
     */
    private boolean deleteDatabaseNow(String dbname, String dblocation) {
        try {
            File dbfile = getDatabaseFile(dbname, dblocation);
            return cordova.getActivity().deleteDatabase(dbfile.getAbsolutePath());
        } catch (Exception e) {
            Log.e(SQLitePlugin.class.getSimpleName(), "couldn't delete database", e);
            return false;
        }
    }

    private class DBRunner implements Runnable {
        final String dbname;
        final String dbkey;
        final String dblocation;
        private boolean bugWorkaround;

        final BlockingQueue<DBQuery> q;
        final CallbackContext openCbc;

        SQLiteAndroidDatabase mydb;

        DBRunner(final String dbname, JSONObject options, CallbackContext cbc) {
            this.dbname = dbname;
            this.bugWorkaround = options.has("androidBugWorkaround");

            String mydblocation = null;
            if (options.has("androidDatabaseLocation")) {
                try {
                    mydblocation = options.getString("androidDatabaseLocation");
                } catch (Exception e) {
                    // IGNORED
                    Log.e(SQLitePlugin.class.getSimpleName(), "unexpected JSON exception, IGNORED", e);
                }
            }
            this.dblocation = mydblocation;

            String key = ""; // (no encryption by default)
            if (options.has("key")) {
                try {
                    key = options.getString("key");
                } catch (JSONException e) {
                    // NOTE: this should not happen!
                    Log.e(SQLitePlugin.class.getSimpleName(), "unexpected JSON error getting password key, ignored", e);
                }
            }
            this.dbkey = key;

            this.q = new LinkedBlockingQueue<DBQuery>();
            this.openCbc = cbc;
        }

        public void run() {
            try {
                this.mydb = openDatabase(dbname, dblocation, this.dbkey, this.openCbc);
            } catch (Exception e) {
                Log.e(SQLitePlugin.class.getSimpleName(), "unexpected error, stopping db thread", e);
                dbrmap.remove(dbname);
                return;
            }

            DBQuery dbq = null;

            try {
                dbq = q.take();

                while (!dbq.stop) {
                    mydb.executeSqlBatch(dbq.queries, dbq.jsonparams, dbq.cbc);
                    dbq = q.take();
                }
            } catch (Exception e) {
                Log.e(SQLitePlugin.class.getSimpleName(), "unexpected error", e);
            }

            if (dbq != null && dbq.close) {
                try {
                    closeDatabaseNow(dbname);

                    dbrmap.remove(dbname); // (should) remove ourself

                    if (!dbq.delete) {
                        dbq.cbc.success();
                    } else {
                        try {
                            boolean deleteResult = deleteDatabaseNow(dbname, dblocation);
                            if (deleteResult) {
                                dbq.cbc.success();
                            } else {
                                dbq.cbc.error("couldn't delete database");
                            }
                        } catch (Exception e) {
                            Log.e(SQLitePlugin.class.getSimpleName(), "couldn't delete database", e);
                            dbq.cbc.error("couldn't delete database: " + e);
                        }
                    }
                } catch (Exception e) {
                    Log.e(SQLitePlugin.class.getSimpleName(), "couldn't close database", e);
                    if (dbq.cbc != null) {
                        dbq.cbc.error("couldn't close database: " + e);
                    }
                }
            }
        }
    }

    private final class DBQuery {
        // XXX TODO replace with DBRunner action enum:
        final boolean stop;
        final boolean close;
        final boolean delete;
        final String[] queries;
        final JSONArray[] jsonparams;
        final CallbackContext cbc;

        DBQuery(String[] myqueries, JSONArray[] params, CallbackContext c) {
            this.stop = false;
            this.close = false;
            this.delete = false;
            this.queries = myqueries;
            this.jsonparams = params;
            this.cbc = c;
        }

        DBQuery(boolean delete, CallbackContext cbc) {
            this.stop = true;
            this.close = true;
            this.delete = delete;
            this.queries = null;
            this.jsonparams = null;
            this.cbc = cbc;
        }

        // signal the DBRunner thread to stop:
        DBQuery() {
            this.stop = true;
            this.close = false;
            this.delete = false;
            this.queries = null;
            this.jsonparams = null;
            this.cbc = null;
        }
    }

    private static enum Action {
        echoStringValue, open, close, delete, executeSqlBatch, backgroundExecuteSqlBatch,
    }
}

/* vim: set expandtab : */
